编译器都做了哪些优化?
The following article is from 程序喵大人 Author 程序喵大人
这是优化系列文章,因为编译优化是成本收益比最高的优化手段,这篇文章主要介绍编译器是如何进行代码优化的,能做什么优化,不能做什么优化,如何充分利用好编译器的优化选项。
现代编译器可以对代码进行大量修改,以提高性能。对于程序员来说,知道编译器能做什么和不能做什么是很有用的,程序员需要了解这些优化。
函数内联
编译器可以用被调用函数的函数体替换函数调用,例如:
float square(float a) {
return a * a;
}
float parabola(float x) {
return square(x) + 1.0f;
}
编译器可以用square内部的代码替换对square的调用:
float parabola(float x) {
return x * x + 1.0f;
}
内联的优势:
1 | 消除了调用、返回和参数传递的开销。 |
2 | 代码缓存效率更高,因为代码变得连续。 |
3 | 如果只有一个内联函数调用,代码就会变小。 |
函数内联的缺点是,如果对内联函数调用多次,而且函数很大,那么代码就会变大。
常量折叠和常量传播
只包含常量的表达式或子表达式将被计算结果替换。例如:
double a, b;
a = b + 2.0 / 3.0;
编译器将会被替换成这样:
a = b + 0.666666666666666666667;
这其实很方便。编写2.0/3.0比计算值并编写许多小数更容易。建议在这样的子表达式周围加一个括号,以确保编译器能识别出它是子表达式。例如,除非我们在常量子表达式周围加了圆括号,否则b*2.0/3.0将被计算为(b*2.0)/3.0而不是b*(2.0/3.0)。
常量可以通过一系列计算传播:
float parabola(float x) {
return x * x + 1.0f;
}
float a, b;
a = parabola(2.0f);
b = a + 1.0f;
编译器可能会替换成这样:
a = 5.0f;
b = 6.0f;
如果表达式包含无法内联或无法在编译时计算的函数,则不可能进行常量折叠和常量传播。例如:
double a = sin(0.8);
sin函数是在一个单独的函数库中定义的,我们不能期望编译器能够内联这个函数,并在编译时计算它。有些编译器能够在编译时计算最常见的数学函数,如sqrt和pow,但不能计算更复杂的函数,如sin。
指针消除
如果指针指向的对象已知,则可以删除指针或引用。例如:
void plus(int *p) {
*p = *p + 2;
}
int a;
plus(&a);
编译器会替换成这样:
a += 2;
公共子表达式消除
如果同一个子表达式出现多次,那么编译器只能计算一次,例如:
int a, b, c
b = (a+1) * (a+1);
c = (a+1) / 4;
编译器会替换成这样:
int a, b, c, temp;
temp = a + 1;
b = temp * temp;
c = temp / 4;
寄存器变量
最常用的变量存储在寄存器中。整数寄存器变量在32位系统中最多大约是6个,在64位系统中大约是14个。浮点寄存器变量在32位系统中最多大约是8个,在64位系统中是16个,在64位代码中启用AVX512指令集时是32个。除非启用了高版本指令集,否则一些编译器在32位系统中,很难创建浮点寄存器变量。
编译器会选择合适的变量存储在寄存器中,包括指针和引用,它们可以存储在整数寄存器中。寄存器变量的典型候选者是临时中间变量、循环计数器、函数参数、指针、引用、this指针、公共子表达等。
如果一个变量被取址,也就是说,如果有一个指向该变量的指针或引用,那么该变量就不能存储在寄存器中。因此,我们应该避免使用任何指向可以从寄存器存储中受益的变量的指针或引用。
活动范围分析
变量的活动范围是使用该变量的代码范围。如果它们的活动范围没有重叠,或者它们肯定有相同的值,编译器优化可以为多个变量使用相同的寄存器。当寄存器的数量有限时,这个优化很有用,例如:
int SomeFunction(int a, int x[]) {
int b, c;
x[0] = a;
b = a + 1;
x[1] = b;
c = b + 1;
return c;
}
这里a、b和c可以共享相同的寄存器,因为它们的活动范围没有重叠。但如果c = b + 1改为c = a + 2,那么a和b不能使用相同的寄存器,因为它们的活动范围重叠了。
编译器通常不会对内存中的变量使用这个原则,即使它们的活动范围没有重叠,它也不会为不同的变量使用相同的内存区域。
为分支提取相同的代码片段
通过提取相同的代码片段,代码可以变得更加紧凑。
double x, y, z; bool b;
if (b) {
y = sin(x);
z = y + 1;
} else {
y = cos(x);
z = y + 1;
}
编译器可以替换为:
double x, y; bool b;
if (b) {
y = sin(x);
} else {
y = cos(x);
}
z = y + 1;
消除跳转
编译器可以通过复制它跳转到的代码来避免跳转,例如:
int someFunction(int a, bool b) {
if (b) a = a * 2;
else a = a * 3;
return a + 1;
}
代码里有一个从a=a*2到return a+1的跳转,编译器可以通过复制return语句来消除跳转:
int someFunction(int a, bool b) {
if (b) {
a = a * 2;
return a + 1;
} else {
a = a * 3;
return a + 1;
}
}
如果条件可以简化为经常为true或者经常为false时,那分支可以被消除,例如:
if (true) {
a = b;
} else {
a = c;
}
这个可以被消除为:
a = b;
如果从前一个分支中知道条件,也可以消除分支。
int someFunction(int a, bool b) {
if (b) a = a * 2;
else a = a * 3;
if (b) return a + 1;
else return a - 1;
}
编译器可以简化为:
int someFunction(int a, bool b) {
if (b) {
a = a * 2;
return a + 1;
} else {
a = a * 3;
return a - 1;
}
}
循环展开
编译器在高度优化下,会展开循环,它可以展开重复次数很低的循环,来避免循环的开销,例如:
int i, a[2];
for (i = 0; i < 2; i++) a[i] = i+1;
编译器可以优化为:
int a[2];
a[0] = 1;
a[1] = 2;
然而有些编译器可能展开得太多,过多的循环展开不一定是好事,因为它占用了代码缓存中太多的空间,并且它填满了许多CPU的循环缓冲区。某些情况下,关闭编译器的loop unroll选项可能很有用。
循环中不变的运算代码
如果某些运算与循环计数器无关,可以将其移出循环。例如:
int i, a[100], b;
for (i = 0; i < 100; i++) {
a[i] = b * b + 1;
}
编译器可以改变它为:
int i, a[100], b, temp;
temp = b * b + 1;
for (i = 0; i < 100; i++) {
a[i] = temp;
}
归纳推导变量
循环计数器的线性函数表达式可以通过在前一个值上加一个常数来计算。例如:
int i, a[100];
for (i = 0; i < 100; i++) {
a[i] = i * 9 + 3;
编译器可以改成这样来避免乘法操作:
int i, a[100], temp;
temp = 3;
for (i = 0; i < 100; i++) {
a[i] = temp;
temp += 9;
}
归纳变量常用于计算数组元素的地址,例如:
struct S1 {double a; double b;};
S1 list[100]; int i;
for (i = 0; i < 100; i++) {
list[i].a = 1.0;
list[i].b = 2.0;
}
为了访问list中的元素,编译器必须计算它的地址。list[i]的地址等于list首地址加上i*sizeof(S1)。这是i的一个线性函数,可以通过归纳推导来计算。编译器可以使用相同的变量访问list[i].a和list[i].b。当可以提前计算出变量的最终值时,就可以消去i,使用归纳变量作为循环计数器。代码可以变为:
struct S1 {double a; double b;};
S1 list[100], *temp;
for (temp = &list[0]; temp < &list[100]; temp++) {
temp->a = 1.0;
temp->b = 2.0;
}
时序安排
为了并行执行,编译器可以对指令重新排序。例如:
float a, b, c, d, e, f, x, y;
x = a + b + c;
y = d + e + f;
在这个例子中,编译器可以交叉计算两个公式,首先计算a + b,然后就是d + e,然后c添加到第一个表达式,那么f被添加到第二个表达式,第一个结果是存储在x中,第二个结果存储在y中。这样可以帮助CPU做并行计算,CPU实际上可以在没有编译器的帮助下对指令重排序,但是编译器可以让CPU更容易地对指令进行重排序。
代数简化
编译器可以灵活运用代数运算定律来简化表达式,例如:
1 | -(-a)简化为a |
2 | if(!a && !b)简化为if(!(a||b)) |
3 | (a*b*c) + (a*b*c)简化为a * b * c * 2 |
整数运算时:
int a, b, c, y;
y = a + b + c;
根据代数运算定律,我们可以写成:
y = c + b + a;
如果子表达式c+b可以在其他地方重用,这可能会很有用。
这种优化对于浮点数运算有些风险,因为浮点数交换顺序会影响精度,看这个例子:
float a = -1.0E8, b = 1.0E8, c = 1.23456, y;
y = a + b + c;
这里的计算给出a+b=0,然后0+1.23456 = 1.23456。但是如果我们改变操作数的顺序,先加上b和c,就不会得到相同的结果。b + c = 100000001.23456。float类型的精度约为7位有效数字,因此b+c的值将四舍五入到100000000。当我们把a加上后,结果是0而不是1.23456。
虚函数优化
编译器如果知道基类指针或引用究竟指向哪个子类对象,那编译器就可以优化,可以绕过虚函数调用的虚函数表查找,如:
class C0 {
public:
virtual void f();
};
class C1 : public C0 {
public:
virtual void f();
};
void g() {
C1 obj1;
C0 *p = &obj1;
p->f(); // Virtual call to C1::f
}
如果没有优化,编译器需要在一个虚表中查找,用于确定p->f()是指向C0::f还是C1::f。如果编译器可以看到p总是指向类C1的对象,那它可以直接调用C1::f,而不需要使用虚表。然而貌似没有编译器能够进行这种优化。
编译器优化的障碍
编译器不是万能的,有些情况下它也不能优化:
不能够跨模块优化
除了正在编译的模块,编译器没有其他模块中函数的信息,所以它不能跨模块进行优化。例:
module1.cpp
int Func1(int x) {
return x*x + 1;
}
module2.cpp
int Func2() {
int a = Func1(2);
...
}
如果Func1和Func2在同一个模块中,那么编译器将能够进行函数内联和常量传播,并将a优化为常量5。但是编译器在编译module2.cpp时没有关于Func1的必要信息。该问题最有效的解决办法,是通过#include指令将多个.cpp模块组合成一个。
指针别名
当通过指针或引用访问变量时,编译器不能完全排除被指向的变量,与其他变量相同的可能性。例如:
void Func1 (int a[], int * p) {
int i;
for (i = 0; i < 100; i++) {
a[i] = *p + 2;
}
}
void Func2() {
int list[100];
Func1(list, &list[8]);
}
这里,需要重新加载*p并计算*p+2表达式100次,因为p指向的值与a[]中的一个元素相同,在循环过程中会发生变化,所以编译器无法做优化。例子确实很坑,但关键是编译器不能排除这种例子存在的理论可能性。因此,编译器不能假定*p+2是一个循环不变表达式,它也就不能移到循环之外。
大多数编译器都有一个选项,来假设没有指针别名。我们需要仔细分析代码中的所有指针和引用,确保在代码的同一部分中,没有以多种方式访问变量或对象。如果编译器支持,也可以使用关键字__restrict或__restrict__来告诉编译器不会有别名。
我们永远不能确定编译器是否收到没有指针别名的提示,所以,确保代码被优化的最好办法是显式地执行。在上例中,如果我们确定指针没有为数组中的任何元素起别名,可以计算*p+2并将其存储在循环外的临时变量中,这种方法需要我们自己能够预测优化的障碍在哪里。
纯函数
纯函数是指没有副作用的函数,其返回值仅取决于其传入参数的值。相同参数的纯函数,每次调用都会产生相同的返回值。编译器可以优化纯函数,可以消除包含纯函数调用的公共子表达式,也可以移出包含纯函数调用的循环不变代码。但是如果是自定义的函数,编译器通常不知道该函数是否为纯函数。
因此,当涉及到纯函数调用时,有必要手动进行诸如公共子表达式消除、常量传播和循环不变代码移动等优化。
我们也可以告诉编译器函数是个纯函数。如:
#ifdef __GNUC__
#define pure_function __attribute__((const))
#else
#define pure_function
#endif
double Func1(double) pure_function ;
double Func2(double x) {
return Func1(x) * Func1(x) + 1.;
}
在这里,Gnu编译器将只调用Func1一次,而其他编译器将调用两次。
虚函数和函数指针
编译器几乎不可能准确预测,将调用哪个版本的虚函数,或者函数指针指向哪个函数。因此,编译器不能对虚函数和函数指针进行内联等优化。
代数简化
编译器可以对一些简单的整数运算进行代数简化优化,但是复杂的就无能为力了,而且也不能对浮点变量进行代数简化,编译器可以对哪种表达式进行优化,哪种不能进行优化,可以看表。
内联函数的非内联拷贝
函数内联有些复杂,因为同一函数可能会被另一个模块调用,编译器为了满足此隐藏条件,就需要对内联函数做一份拷贝,然而,如果没有其它模块调用此函数,那就浪费了这次拷贝操作,而且浪费代码空间。
怎么解决此问题呢?函数有没有被其它模块调用,编译器不知道,但是程序员是知道的,如果没有被其它模块调用,我们可以用static修饰函数,表示本地使用的意思,但是static修饰类成员函数却有不同意义,然而类成员函数可以直接使用inline修饰。
Linux编译器有个选项-function-sections,可以使链接器删除未引用的函数,我们可以打开这个选项。
编译器优化选项
所有的编译器都有各种各样的优化选项,我们可以手动选择是否打开,有必要研究我们正在使用的编译器优化选项。
编译器所有的优化选项可以看我之前的这篇文章:Debug模式和Release模式有什么区别?
一般我们开发的程序都有两个版本,Debug版本和Release版本,Debug版本是调试版本,该版本通常不带任何优化动作,发布程序的时候会使用Release版本,该版本会对程序做很多优化。优化有两个方向,一个是程序体积方向,一个是速度方向,当速度优化到极致时,有可能程序体积不是最小,当程序体积优化到最小时,有可能程序执行速度不是最快,我们应该自己权衡,通常情况下,最高的优化选项是最好的,只有某些情况下,它才不是最优解。
1 | 当不需要与老版本CPU兼容时,我们可以选择使用最新的指令集。 |
2 | 我们可以考虑不使用异常处理,并关闭编译器对异常处理的支持。 |
3 | 可以考虑关闭RTTI。 |
4 | 可以启用打开浮点数快速计算的选项。 |
5 | 如果我们确定代码没有指针别名,可以使用"assume no pointer aliasing"选项。 |
6 | 很多编译器使用标准栈帧选项,该栈帧用于调试和异常处理,会占用寄存器资源,而寄存器是一种稀缺资源,我们可以考虑选择省略标准栈帧选项,将寄存器用于其它目的。 |
优化指令
一些编译器有许多关键字和指令,用于在代码的特定位置给出特定的优化提示。某些指令中有许多是编译器特有的。不能指望Windows编译器的指令能在Linux编译器上工作,反之亦然。但是大多数Microsoft指令在Windows的Intel编译器和Windows的Gnu编译器上工作,而大多数Gnu指令在Linux的PathScale和Intel编译器上工作。
关键字volatile,确保变量永远不会存储在寄存器中,每次读写都是在内存中。它用于多个线程之间共享的变量,并关闭变量相关的很多优化。
const关键字表示变量永远不会改变,编译器在许多情况下可能优化掉此变量。如:
const int ArraySize = 1000;
int List[ArraySize];
...
for (int i = 0; i < ArraySize; i++) List[i]++;
在这里,编译器可以用值1000替换所有出现的ArraySize。如果循环计数(ArraySize)的值是常量,并且编译器在编译时知道,那么上例中的循环可以用一种更有效的方式实现。除非它的地址(&ArraySize)被占用,否则不会为一个整数常量分配内存。
const指针或const引用不能改变它所指向的对象,const成员函数不能修改数据成员,我们可以考虑适当使用const关键字,给编译器更多优化提示,提高优化的可能性。
noexcept可修饰某些函数,用于提示编译器此函数永远不会产生异常,编译器可以对此进行更多优化。
static关键字根据上下文有几种含义。
当static修饰非成员函数时,表示该函数不能被其它任何模块访问,方便编译器进行内联和过程间优化。
当static修饰全局变量时,表示它不会被任何其他模块访问,方便编译器过程间优化。
当static修饰函数体内的局部变量时,表示该变量在函数返回时将被保留,并在下次调用函数时保持不变。这可能导致效率低下,因为一些编译器会插入额外的代码,以保护变量不被多个线程同时访问。即使变量是const,也是如此。
然而,有时候需要将局部变量设为static和const,确保只有在第一次调用函数时才对其进行初始化。如:
void Func () {
static const double log2 = log(2.0);
...
}
这里,log(2.0)只在第一次执行Func时计算,但如果没有static,每次调用Func时都会重新计算对数。这样做的缺点是,函数必须检查它以前是否被调用过,但这也比重新计算对数要快,将log2作为全局const变量,或用计算后的值替换它,速度会更快。
用static修饰类成员函数时,就代表它不能访问任何非静态数据成员或成员函数,静态成员函数比非静态成员函数调用得更快,因为它不需要this指针。我们可以适当的时候将成员函数用static修饰。
编译器特定的优化指令
1 | 快速函数调用:__attribute__ ((fastcall)),仅适用于32位模式,该饰符可以使函数调用在32位模式下更快,前两个整数参数在寄存器中传输,而不是在栈中传输。 |
2 | 纯函数:__attribute__((const)),Linux特有。 |
3 | 指定没有指针重叠:__declspec(noalias)或__restrict或#pragma optimize ("a",on),这些指令不一定经常有效。 |
4 | 数据对齐:__declspec(align(16))或__attribute__((aligned(16))),C++11中可用alignas(16)。 |
不同的编译器对比
不同编译器对于不同操作优化的支持能力数据来源于网络,这里我整理了一份脑图分享给大家,部分节点图如下:
总之结论就是,gnu和clang编译器最好,获取完整脑图大家可在公众号后台回复编译优化关键字获取。
参考资料
https://www.agner.org/optimize/
- EOF -
关注『CPP开发者』
看精选C++技术文章 . 加C++开发者专属圈子
点赞和在看就是最大的支持❤️